/*
* JBoss, Home of Professional Open Source
* Copyright 2013, Red Hat, Inc. and/or its affiliates, and individual
* contributors by the @authors tag. See the copyright.txt in the
* distribution for a full listing of individual contributors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jboss.totp;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Calendar;
import java.util.Random;
import java.util.Timer;
import java.util.TimerTask;
import javax.microedition.lcdui.Alert;
import javax.microedition.lcdui.AlertType;
import javax.microedition.lcdui.Choice;
import javax.microedition.lcdui.ChoiceGroup;
import javax.microedition.lcdui.Command;
import javax.microedition.lcdui.CommandListener;
import javax.microedition.lcdui.Display;
import javax.microedition.lcdui.Displayable;
import javax.microedition.lcdui.Form;
import javax.microedition.lcdui.Gauge;
import javax.microedition.lcdui.List;
import javax.microedition.lcdui.StringItem;
import javax.microedition.lcdui.TextField;
import javax.microedition.midlet.MIDlet;
import javax.microedition.rms.RecordEnumeration;
import javax.microedition.rms.RecordStore;
import javax.microedition.rms.RecordStoreException;
import javax.microedition.rms.RecordStoreNotFoundException;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.digests.SHA1Digest;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.digests.SHA512Digest;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
/**
* TOTP generator for Java ME.
*
* @author Josef Cacek
*/
public class TOTPMIDlet extends MIDlet implements CommandListener {
private static final boolean DEBUG = false;
private static final String STORE_CONFIG_OLD = "config";
private static final String STORE_PROFILE_CONFIG = "profile-config";
private static final String STORE_KEY_OLD = "key";
private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static final int[] BASE32_LOOKUP = { 0xFF, 0xFF, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
private static final char[] HEX_TABLE = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
'e', 'f' };
private static final String SHA1 = "SHA-1";
private static final String SHA256 = "SHA-256";
private static final String SHA512 = "SHA-512";
private static final String[] HMAC_ALGORITHMS = { SHA1, SHA256, SHA512 };
private static final int[] HMAC_BYTE_COUNT = { 160 / 8, 256 / 8, 512 / 8 };
private static final int DEFAULT_TIMESTEP = 30;
private static final byte[] DEFAULT_SECRET = null;
private static final int DEFAULT_DIGITS = 6;
private static final long DEFAULT_DELTA = 0L;
private static final int DEFAULT_HMAC_ALG_IDX = 0;
private static final String DEFAULT_PROFILE = "Default";
private static final long INVALID_COUNTER = -1L;
private static final int INDEFINITE = 1;
private static final int IDLE = 0;
private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
private static final byte[] DEFAULT_CONFIG_BYTES = getProfileConfig(DEFAULT_PROFILE, EMPTY_BYTE_ARRAY,
DEFAULT_TIMESTEP, DEFAULT_HMAC_ALG_IDX, DEFAULT_DIGITS, DEFAULT_DELTA);
// GUI components
// main screen
private Command cmdExit = new Command("Exit", Command.EXIT, 1);
private Command cmdProfiles = new Command("Profiles", Command.SCREEN, 2);
private Command cmdOptions = new Command("Options", Command.SCREEN, 3);
// main+options screen
private Command cmdGenerator = new Command("Key generator", Command.SCREEN, 4);
// options screen
private Command cmdOK = new Command("OK", Command.OK, 1);
private Command cmdReset = new Command("Default values", Command.SCREEN, 3);
// keyGenerator screen
private Command cmdNewKey = new Command("New key", Command.SCREEN, 1);
private Command cmdGeneratorOK = new Command("OK", Command.OK, 1);
// profiles screen
private Command cmdAddProfile = new Command("Add", Command.SCREEN, 1);
private Command cmdRemoveProfile = new Command("Remove", Command.SCREEN, 2);
// confirmation screen
private Command cmdCancel = new Command("Cancel", Command.CANCEL, 1);
private final StringItem siKeyHex = new StringItem("HEX", null);
private final StringItem siKeyBase32 = new StringItem("Base32 (no zeros)", null);
private final StringItem siToken = new StringItem("Token", null);
private final StringItem siProfile = new StringItem(null, null);
private final StringItem siConfirm = new StringItem(null, null);
private final Gauge gauValidity = new Gauge(null, false, DEFAULT_TIMESTEP - 1, DEFAULT_TIMESTEP);
private final TextField tfSecret = new TextField("Secret key (Base32, no zeros)", null, 105, TextField.ANY);
private final TextField tfProfile = new TextField("Profile name", null, 105, TextField.ANY);
private final TextField tfTimeStep = new TextField("Time step (sec)", String.valueOf(DEFAULT_TIMESTEP), 3,
TextField.NUMERIC);
private final TextField tfDigits = new TextField("Number of digits", String.valueOf(DEFAULT_DIGITS), 2,
TextField.NUMERIC);
//http://docs.oracle.com/javame/config/cldc/ref-impl/midp2.0/jsr118/javax/microedition/lcdui/TextField.htm getMaxSize
private final TextField tfDelta = new TextField("Time correction (sec)", String.valueOf(DEFAULT_DELTA), 20,
TextField.ANY);
private final ChoiceGroup chgHmacAlgorithm = new ChoiceGroup("HMAC algorithm", Choice.EXCLUSIVE);
private final Alert alertWarn = new Alert("Warning", "Something went wrong!", null, AlertType.ALARM);
private final Form fMain = new Form("TOTP ME ${project.version}");
private final Form fOptions = new Form("TOTP configuration");
private final Form fGenerator = new Form("Key generator");
private final Form fConfirm = new Form("Confirm action");
private final List listProfiles = new List("Profiles", Choice.IMPLICIT);
private final Timer timer = new Timer();
private final RefreshTokenTask refreshTokenTask = new RefreshTokenTask();
private long cachedCounter;
private HMac hmac;
private final Random rand = new Random();
private int[] recordIds;
// Constructors ----------------------------------------------------------
/**
* Constructor - initializes GUI components.
*/
public TOTPMIDlet() {
// Main display
fMain.append(siToken);
fMain.append(gauValidity);
fMain.append(siProfile);
fMain.addCommand(cmdExit);
fMain.addCommand(cmdProfiles);
fMain.addCommand(cmdOptions);
fMain.addCommand(cmdGenerator);
fMain.setCommandListener(this);
// Key generator
fGenerator.append(siKeyHex);
fGenerator.append(siKeyBase32);
fGenerator.addCommand(cmdGeneratorOK);
fGenerator.addCommand(cmdNewKey);
fGenerator.setCommandListener(this);
// Configuration display
fOptions.append(tfSecret);
fOptions.append(tfProfile);
fOptions.append(tfTimeStep);
fOptions.append(tfDigits);
for (int i = 0; i < HMAC_ALGORITHMS.length; i++) {
chgHmacAlgorithm.append(HMAC_ALGORITHMS[i], null);
}
fOptions.append(chgHmacAlgorithm);
fOptions.append(tfDelta);
fOptions.addCommand(cmdOK);
fOptions.addCommand(cmdGenerator);
fOptions.addCommand(cmdReset);
fOptions.setCommandListener(this);
// Profiles
listProfiles.addCommand(cmdAddProfile);
listProfiles.addCommand(cmdRemoveProfile);
listProfiles.setCommandListener(this);
// Confirm dialog
fConfirm.append(siConfirm);
fConfirm.addCommand(cmdOK);
fConfirm.addCommand(cmdCancel);
fConfirm.setCommandListener(this);
// set alert
alertWarn.setTimeout(Alert.FOREVER);
tfTimeStep.setString(String.valueOf(DEFAULT_TIMESTEP));
tfDigits.setString(String.valueOf(DEFAULT_DIGITS));
chgHmacAlgorithm.setSelectedIndex(DEFAULT_HMAC_ALG_IDX, true);
}
// Public methods --------------------------------------------------------
/**
* Loads configuration and initializes token-refreshing timer.
*
* @see javax.microedition.midlet.MIDlet#startApp()
*/
public void startApp() {
try {
loadProfiles();
reorderProfiles();
if (listProfiles.getSelectedIndex() < 0)
listProfiles.setSelectedIndex(0, true);
if (listProfiles.size() > 1) {
Display.getDisplay(this).setCurrent(listProfiles);
} else {
loadSelectedProfile();
}
timer.schedule(refreshTokenTask, 0L, 1000L);
} catch (Exception e) {
debugErr("TOTPMIDlet.startApp() - " + e.getMessage());
error(e);
}
}
/*
* (non-Javadoc)
*
* @see javax.microedition.midlet.MIDlet#pauseApp()
*/
public void pauseApp() {
}
/**
* Saves configuration to the record store and exits the refreshing timer.
*
* @see javax.microedition.midlet.MIDlet#destroyApp(boolean)
*/
public void destroyApp(boolean unconditional) {
refreshTokenTask.cancel();
timer.cancel();
notifyDestroyed();
}
/**
* Handles command actions from all forms.
*
* @see javax.microedition.lcdui.CommandListener#commandAction(javax.microedition.lcdui.Command,
* javax.microedition.lcdui.Displayable)
*/
public void commandAction(Command aCmd, Displayable aDisp) {
if (DEBUG && aCmd != null) {
debug("Options - Command action: " + aCmd.getLabel());
}
final Display display = Display.getDisplay(this);
if (aDisp == fConfirm) {
if (aCmd == cmdOK) {
removeProfile(listProfiles.getSelectedIndex());
}
display.setCurrent(listProfiles);
return;
}
if (aCmd == cmdOK) {
final String warning = validateInput();
if (warning.length() == 0) {
siProfile.setText(tfProfile.getString());
final int algorithmIdx = chgHmacAlgorithm.getSelectedIndex();
final byte[] secretKey = base32Decode(tfSecret.getString());
HMac newHmac = null;
if (secretKey != null) {
Digest digest = null;
if (SHA1.equals(HMAC_ALGORITHMS[algorithmIdx])) {
digest = new SHA1Digest();
} else if (SHA256.equals(HMAC_ALGORITHMS[algorithmIdx])) {
digest = new SHA256Digest();
} else if (SHA512.equals(HMAC_ALGORITHMS[algorithmIdx])) {
digest = new SHA512Digest();
}
newHmac = new HMac(digest);
newHmac.init(new KeyParameter(secretKey));
}
setHMac(newHmac);
refreshTokenTask.run();
display.setCurrent(fMain);
if (aDisp != null)
save();
reorderProfiles();
} else {
displayAlert("Invalid input:\n" + warning, fOptions);
}
} else if (aCmd == cmdGenerator) {
final byte[] key = base32Decode(tfSecret.getString());
// set current key
siKeyHex.setText(key == null ? "" : toHexString(key, 0, key.length));
siKeyBase32.setText(base32Encode(key));
display.setCurrent(fGenerator);
} else if (aCmd == cmdProfiles) {
display.setCurrent(listProfiles);
} else if (aCmd == cmdAddProfile) {
final Calendar cal = Calendar.getInstance();
// use date-time as generated profile name YYYYMMDD-HHMMSS
final String profileName = cal.get(Calendar.YEAR) + zeroLeftPad(cal.get(Calendar.MONTH) + 1, 2)
+ zeroLeftPad(cal.get(Calendar.DAY_OF_MONTH), 2) + "-"
+ zeroLeftPad(cal.get(Calendar.HOUR_OF_DAY), 2) + zeroLeftPad(cal.get(Calendar.MINUTE), 2)
+ zeroLeftPad(cal.get(Calendar.SECOND), 2);
if (DEBUG)
debug("Creating profile" + profileName);
int newPos = 0;
while (newPos < listProfiles.size() && profileName.compareTo(listProfiles.getString(newPos)) > 0) {
newPos++;
}
listProfiles.insert(newPos, profileName, null);
listProfiles.setSelectedIndex(newPos, true);
int[] newRecIds = new int[recordIds.length + 1];
System.arraycopy(recordIds, 0, newRecIds, 0, newPos);
System.arraycopy(recordIds, newPos, newRecIds, newPos + 1, recordIds.length - newPos);
recordIds = newRecIds;
final byte[] profileConfig = getProfileConfig(profileName, EMPTY_BYTE_ARRAY, DEFAULT_TIMESTEP,
DEFAULT_HMAC_ALG_IDX, DEFAULT_DIGITS, DEFAULT_DELTA);
recordIds[newPos] = addProfileToRecordStore(profileConfig);
} else if (aDisp == listProfiles && aCmd == List.SELECT_COMMAND) {
if (listProfiles.getSelectedIndex() >= 0)
loadSelectedProfile();
} else if (aCmd == cmdRemoveProfile) {
switch (listProfiles.size()) {
case 0:
displayAlert("There is no profile to delete.", listProfiles);
break;
case 1:
displayAlert("You can't remove the last profile.", listProfiles);
break;
default:
if (listProfiles.getSelectedIndex() >= 0) {
siConfirm.setText("Do you really want to delete profile "
+ listProfiles.getString(listProfiles.getSelectedIndex()) + "?");
display.setCurrent(fConfirm);
}
break;
}
} else if (aCmd == cmdNewKey) {
final byte[] secretKey = generateNewKey();
siKeyHex.setText(toHexString(secretKey, 0, secretKey.length));
siKeyBase32.setText(base32Encode(secretKey));
tfSecret.setString(siKeyBase32.getText());
} else if (aCmd == cmdGeneratorOK) {
display.setCurrent(fOptions);
} else if (aCmd == cmdOptions) {
display.setCurrent(fOptions);
} else if (aCmd == cmdReset) {
setHMac(null);
gauValidity.setMaxValue(INDEFINITE);
gauValidity.setValue(IDLE);
tfSecret.setString(base32Encode(DEFAULT_SECRET));
tfTimeStep.setString(Integer.toString(DEFAULT_TIMESTEP));
tfDigits.setString(Integer.toString(DEFAULT_DIGITS));
chgHmacAlgorithm.setSelectedIndex(DEFAULT_HMAC_ALG_IDX, true);
tfDelta.setString(Long.toString(DEFAULT_DELTA));
tfProfile.setString(listProfiles.getString(listProfiles.getSelectedIndex()));
} else if (aCmd == cmdExit) {
destroyApp(false);
}
cachedCounter = INVALID_COUNTER;
}
// Protected methods -----------------------------------------------------
/**
* Generates the current token. If the token can't be generated it returns
* an empty String.
*
* @return current token or an empty String
*/
protected static String genToken(final long counter, final HMac hmac, final int digits) {
if (hmac == null || digits <= 0) {
return "";
}
// generate 8 byte HOTP counter value (RFC 4226)
final byte msg[] = new byte[8];
for (int i = 0; i < 8; i++) {
msg[7 - i] = (byte) (counter >>> (i * 8));
}
// compute the HMAC
final byte[] hash = new byte[hmac.getMacSize()];
hmac.update(msg, 0, msg.length);
hmac.doFinal(hash, 0);
// Transform the HMAC to a HOTP value according to RFC 4226.
final int off = hash[hash.length - 1] & 0xF;
// Truncate the HMAC (look at RFC 4226 section 5.3, step 2).
int binary = ((hash[off] & 0x7f) << 24) | ((hash[off + 1] & 0xff) << 16) | ((hash[off + 2] & 0xff) << 8)
| ((hash[off + 3] & 0xff));
// use requested number of digits
final byte[] digitsArray = new byte[digits];
for (int i = 0; i < digits; i++) {
digitsArray[digits - 1 - i] = (byte) ('0' + (char) (binary % 10));
binary /= 10;
}
return new String(digitsArray, 0, digits);
}
/**
* Returns counter value for given time and timeStep.
*
* @param timeInSec
* @param timeStep
* @return counter (HOTP)
*/
protected static long getCounter(final long timeInSec, final int timeStep) {
return timeStep > 0 ? timeInSec / timeStep : INVALID_COUNTER;
}
// Private methods -------------------------------------------------------
/**
* Returns HMac.
*
* @return
*/
private synchronized HMac getHMac() {
return hmac;
}
/**
* Sets HMac.
*
* @param hmac
*/
private synchronized void setHMac(HMac hmac) {
this.hmac = hmac;
}
/**
* Validates (and makes basic corrections in) the options form. It returns
* warning message(s) if the validation error occurs. An empty string is
* returned if the validation is successful.
*
* @return warning message
*/
private String validateInput() {
final StringBuffer warnings = new StringBuffer();
int algIdx = chgHmacAlgorithm.getSelectedIndex();
if (algIdx < 0) {
algIdx = 0;
chgHmacAlgorithm.setSelectedIndex(algIdx, true);
}
String str = tfSecret.getString();
if (str == null) {
str = "";
} else {
final StringBuffer sb = new StringBuffer();
str = str.toUpperCase().replace('0', 'O').replace('1', 'L');
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
if (BASE32_CHARS.indexOf(ch) >= 0) {
sb.append(ch);
}
}
str = sb.toString();
}
tfSecret.setString(str);
int step = 0;
try {
step = Integer.parseInt(tfTimeStep.getString());
} catch (NumberFormatException e) {
tfTimeStep.setString(Integer.toString(DEFAULT_TIMESTEP));
step = DEFAULT_TIMESTEP;
}
if (step <= 0) {
if (warnings.length() > 0)
warnings.append("\n");
warnings.append("Time step must be positive number.");
}
gauValidity.setMaxValue((str.length() > 0 && step > 1) ? step - 1 : INDEFINITE);
int digits = 0;
try {
digits = Integer.parseInt(tfDigits.getString());
} catch (NumberFormatException e) {
tfDigits.setString(Integer.toString(DEFAULT_DIGITS));
}
if (digits <= 0) {
if (warnings.length() > 0)
warnings.append("\n");
warnings.append("Number of digits must be positive number.");
}
//Example: If device manufactur has set limited lifetime [2000..2017)
//In Browser console: (Date.UTC(2017,0,1)-Date.UTC(2000,0,1))/1000
//delta = 536544000
long delta = 0L;
try {
delta = Long.parseLong(tfDelta.getString());
} catch (NumberFormatException e) {
tfDelta.setString(Long.toString(DEFAULT_DELTA));
if (warnings.length() > 0)
warnings.append("\n");
warnings.append("Incorrect delta value '");
warnings.append(tfDelta.getString());
warnings.append("' (is not a number).");
}
return warnings.toString();
}
/**
* Shows {@link Alert} warning screen with given message.
*
* @param msg
* @param nextDisplayable
* Next screen, which is displayed after warning confirmation by
* a user.
*/
private void displayAlert(final String msg, Displayable nextDisplayable) {
alertWarn.setString(msg);
Display.getDisplay(this).setCurrent(alertWarn, nextDisplayable);
}
/**
* Adds new profile to {@link RecordStore} and returns ID of the new record.
* It returns -1 if adding fails.
*
* @param configBytes
* byte array profile representation
* @return new record ID or -1 (if adding fails)
*/
private int addProfileToRecordStore(final byte[] configBytes) {
RecordStore tmpRS = null;
try {
tmpRS = RecordStore.openRecordStore(STORE_PROFILE_CONFIG, true);
return tmpRS.addRecord(configBytes, 0, configBytes.length);
} catch (Exception e) {
debugErr("addProfile - " + e.getClass().getName() + " - " + e.getMessage());
} finally {
if (tmpRS != null) {
try {
tmpRS.closeRecordStore();
} catch (RecordStoreException e) {
debugErr("addProfile (close) - " + e.getClass().getName() + " - " + e.getMessage());
}
}
}
return -1;
}
/**
* Removes record with given ID from a {@link RecordStore} with given name.
*
* @param storeName
* @param recordId
*/
private void removeRecordFromStore(final String storeName, final int recordId) {
RecordStore tmpRS = null;
if (DEBUG)
debug("removeRecordFromStore - " + storeName + " - " + recordId);
try {
tmpRS = RecordStore.openRecordStore(storeName, false);
tmpRS.deleteRecord(recordId);
} catch (RecordStoreNotFoundException e) {
if (DEBUG)
debug("removeRecordFromStore - RecordStoreNotFoundException - " + storeName);
} catch (Exception e) {
debugErr("removeRecordFromStore - " + e.getClass().getName() + " - " + storeName + " - " + recordId + ": "
+ e.getMessage());
} finally {
if (tmpRS != null) {
try {
tmpRS.closeRecordStore();
} catch (RecordStoreException e) {
debugErr("removeRecordFromStore (close) - " + e.getClass().getName() + " - " + storeName + " - "
+ recordId + ": " + e.getMessage());
}
}
}
}
/**
* Sets record with given ID and value to a {@link RecordStore} with given
* name.
*
* @param storeName
* @param recordId
* @param value
* @return
*/
private boolean saveRecordToStore(final String storeName, final int recordId, final byte[] value) {
RecordStore tmpRS = null;
try {
tmpRS = RecordStore.openRecordStore(storeName, true);
tmpRS.setRecord(recordId, value, 0, value.length);
} catch (Exception e) {
debugErr("saveRecordToStore - " + e.getClass().getName() + " - " + storeName + " - " + recordId + ": "
+ e.getMessage());
return false;
} finally {
if (tmpRS != null) {
try {
tmpRS.closeRecordStore();
} catch (RecordStoreException e) {
debugErr("saveRecordToStore (close) - " + e.getClass().getName() + " - " + storeName + " - "
+ recordId + ": " + e.getMessage());
}
}
}
return true;
}
/**
* Loads value of a record with given ID from a {@link RecordStore} with
* given name.
*
* @param storeName
* @param recordId
* @return
*/
private byte[] loadRecordFromStore(final String storeName, final int recordId) {
RecordStore tmpRS = null;
byte[] value = EMPTY_BYTE_ARRAY;
try {
tmpRS = RecordStore.openRecordStore(storeName, false);
value = tmpRS.getRecord(recordId);
} catch (RecordStoreNotFoundException e) {
if (DEBUG) {
debug("loadRecordFromStore - RecordStoreNotFoundException - " + storeName);
}
} catch (Exception e) {
debugErr("loadRecordFromStore - " + e.getClass().getName() + " - " + storeName + " - " + recordId + ": "
+ e.getMessage());
} finally {
if (tmpRS != null) {
try {
tmpRS.closeRecordStore();
} catch (RecordStoreException e) {
debugErr("loadRecordFromStore (close) - " + e.getClass().getName() + " - " + storeName + " - "
+ recordId + ": " + e.getMessage());
}
}
}
return value;
}
/**
* Removes profile with given index from GUI list and the
* {@link RecordStore}.
*
* @param profileIdx
*/
private void removeProfile(final int profileIdx) {
if (profileIdx >= listProfiles.size() || profileIdx < 0) {
return;
}
listProfiles.delete(profileIdx);
listProfiles.setSelectedIndex(profileIdx < listProfiles.size() ? profileIdx : profileIdx - 1, true);
removeRecordFromStore(STORE_PROFILE_CONFIG, recordIds[profileIdx]);
int[] newRecIds = new int[recordIds.length - 1];
System.arraycopy(recordIds, 0, newRecIds, 0, profileIdx);
System.arraycopy(recordIds, profileIdx + 1, newRecIds, profileIdx, newRecIds.length - profileIdx);
recordIds = newRecIds;
}
/**
* Loads configuration of the selected profile.
*/
private void loadSelectedProfile() {
final int profileIdx = listProfiles.getSelectedIndex();
// load from profile
debug("Loading profile config record.");
final byte[] profileConfig = loadRecordFromStore(STORE_PROFILE_CONFIG, recordIds[profileIdx]);
ByteArrayInputStream bais = new ByteArrayInputStream(profileConfig);
DataInputStream dis = new DataInputStream(bais);
String base32EncodedSecret = "";
try {
tfProfile.setString(dis.readUTF());
byte[] key = new byte[dis.readByte()];
dis.readFully(key);
base32EncodedSecret = base32Encode(key);
tfTimeStep.setString(String.valueOf(dis.readInt()));
chgHmacAlgorithm.setSelectedIndex(dis.readInt(), true);
tfDigits.setString(String.valueOf(dis.readByte()));
tfDelta.setString(String.valueOf(dis.readLong()));
} catch (Exception e) {
e.printStackTrace();
debugErr("loading profile configuration - " + e.getClass().getName() + " - " + e.getMessage());
} finally {
try {
dis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
tfSecret.setString(base32EncodedSecret);
siProfile.setText(tfProfile.getString());
siToken.setText("");
int timeStep = -1;
try {
timeStep = Integer.parseInt(tfTimeStep.getString());
} catch (NumberFormatException e) {
debugErr(e.getMessage());
}
gauValidity.setMaxValue((base32EncodedSecret.length() > 0 && timeStep > 1) ? timeStep - 1 : INDEFINITE);
if (base32EncodedSecret.length() == 0) {
final Display display = Display.getDisplay(this);
display.setCurrent(fOptions);
} else {
// use validation - to check loaded data
commandAction(cmdOK, null);
}
}
/**
* Loads list of profile names and IDs from the {@link RecordStore}.
*/
private void loadProfiles() {
RecordStore tmpRS = null;
recordIds = new int[0];
try {
tmpRS = RecordStore.openRecordStore(STORE_PROFILE_CONFIG, true);
if (tmpRS.getNumRecords() == 0) {
byte[] newRecord = DEFAULT_CONFIG_BYTES;
// try to load old-style (1.3) configuration
byte[] secret = loadRecordFromStore(STORE_KEY_OLD, 1);
if (secret.length > 0) {
debug("Loading old config.");
final byte[] configBytes = loadRecordFromStore(STORE_CONFIG_OLD, 1);
final ByteArrayInputStream bais = new ByteArrayInputStream(configBytes);
final DataInputStream dis = new DataInputStream(bais);
int ts = DEFAULT_TIMESTEP, idx = DEFAULT_HMAC_ALG_IDX;
long delta = DEFAULT_DELTA;
int digits = DEFAULT_DIGITS;
try {
ts = dis.readInt();
idx = dis.readInt();
digits = dis.readByte();
delta = dis.readLong();
} catch (Exception e) {
debugErr("loading old configuration - " + e.getClass().getName() + " - " + e.getMessage());
} finally {
try {
dis.close();
} catch (IOException e) {
debugErr("loading old configuration (close) - " + e.getClass().getName() + " - "
+ e.getMessage());
}
}
newRecord = getProfileConfig(DEFAULT_PROFILE, secret, ts, idx, digits, delta);
try {
RecordStore.deleteRecordStore(STORE_KEY_OLD);
} catch (RecordStoreException e) {
// nothing to do here
}
try {
RecordStore.deleteRecordStore(STORE_CONFIG_OLD);
} catch (RecordStoreException e) {
// nothing to do here
}
}
debug("Adding new configuration record.");
tmpRS.addRecord(newRecord, 0, newRecord.length);
}
// load profile record IDs
recordIds = new int[tmpRS.getNumRecords()];
RecordEnumeration recEnum = tmpRS.enumerateRecords(null, null, false);
int i = 0;
while (recEnum.hasNextElement()) {
recordIds[i++] = recEnum.nextRecordId();
}
// load profile names
for (i = 0; i < recordIds.length; i++) {
final int recordId = recordIds[i];
debug("Parsing profile name for record " + recordId);
final String profileName = parseProfileName(tmpRS.getRecord(recordId));
debug("Parsed profile name: " + profileName);
listProfiles.append(profileName, null);
}
} catch (Exception e) {
debugErr("loadProfiles - " + e.getClass().getName() + " - " + e.getMessage());
} finally {
if (tmpRS != null) {
try {
tmpRS.closeRecordStore();
} catch (RecordStoreException e) {
debug("loadProfiles (close) - " + e.getClass().getName() + " - " + e.getMessage());
}
}
}
}
/**
* Returns profile name from given profile record value.
*
* @param profileBytes
* @return profile name
*/
private String parseProfileName(byte[] profileBytes) {
final ByteArrayInputStream bais = new ByteArrayInputStream(profileBytes);
final DataInputStream dis = new DataInputStream(bais);
try {
return dis.readUTF();
} catch (IOException e) {
debugErr(e.getMessage());
} finally {
try {
dis.close();
} catch (IOException e) {
debugErr(e.getMessage());
}
}
return DEFAULT_PROFILE;
}
/**
* Saves profile to a record store.
*/
private void save() {
final int profileIdx = listProfiles.getSelectedIndex();
final int recordId = recordIds[profileIdx];
// store configuration of current profile
final byte[] configBytes = getProfileConfig(tfProfile.getString(), base32Decode(tfSecret.getString()),
Integer.parseInt(tfTimeStep.getString()), chgHmacAlgorithm.getSelectedIndex(),
Integer.parseInt(tfDigits.getString()), Long.parseLong(tfDelta.getString()));
saveRecordToStore(STORE_PROFILE_CONFIG, recordId, configBytes);
// update also profile name
listProfiles.set(profileIdx, tfProfile.getString(), null);
}
/**
* Creates profile record from provided values.
*
* @param profileName
* @param key
* @param timeStep
* @param hmacIdx
* @param digits
* @param delta
* @return
*/
private static byte[] getProfileConfig(String profileName, byte[] key, int timeStep, int hmacIdx, int digits,
long delta) {
if (key == null)
key = EMPTY_BYTE_ARRAY;
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
final DataOutputStream dos = new DataOutputStream(baos);
try {
dos.writeUTF(profileName);
dos.writeByte(key.length);
dos.write(key);
dos.writeInt(timeStep);
dos.writeInt(hmacIdx);
dos.writeByte(digits);
dos.writeLong(delta);
} catch (IOException e) {
debugErr("Creating configuration failed - " + e.getMessage());
} finally {
try {
dos.close();
} catch (IOException e) {
debugErr("Creating configuration failed (close)- " + e.getMessage());
}
}
return baos.toByteArray();
}
/**
* Debug function
*
* @param aWhat
*/
private static void debug(final String aWhat) {
if (DEBUG) {
System.out.println(">>>DEBUG " + aWhat);
}
}
/**
* Debug function for errors
*
* @param aWhat
*/
private static void debugErr(final String aWhat) {
if (DEBUG) {
System.err.println(">>>ERROR " + aWhat);
}
}
/**
* Prints error.
*
* @param anErr
*/
private static void error(final Object anErr) {
if (anErr instanceof Throwable) {
((Throwable) anErr).printStackTrace();
} else {
System.err.println(">>>ERROR " + anErr);
}
}
/**
* Encodes byte array to Base32 String. Returns not-null String.
*
* @param bytes
* Bytes to encode.
* @return Encoded byte array <code>bytes</code> as a String.
*
*/
private static String base32Encode(final byte[] bytes) {
if (bytes == null) {
return "";
}
int i = 0, index = 0, digit = 0, outchars = 0;
int currByte, nextByte;
StringBuffer base32 = new StringBuffer((bytes.length + 7) * 8 / 5);
while (i < bytes.length) {
currByte = (bytes[i] >= 0) ? bytes[i] : (bytes[i] + 256); // unsign
// Is the current digit going to span a byte boundary?
if (index > 3) {
if ((i + 1) < bytes.length) {
nextByte = (bytes[i + 1] >= 0) ? bytes[i + 1] : (bytes[i + 1] + 256);
} else {
nextByte = 0;
}
digit = currByte & (0xFF >> index);
index = (index + 5) % 8;
digit <<= index;
digit |= nextByte >> (8 - index);
i++;
} else {
digit = (currByte >> (8 - (index + 5))) & 0x1F;
index = (index + 5) % 8;
if (index == 0)
i++;
}
base32.append(BASE32_CHARS.charAt(digit));
outchars++;
if (outchars % 4 == 0)
base32.append(" ");
}
return base32.toString();
}
/**
* Decodes the given Base32 String to a raw byte array.
*
* @param base32
* @return Decoded <code>base32</code> String as a raw byte array.
*/
private static byte[] base32Decode(final String aBase32) {
if (aBase32 == null || aBase32.length() == 0)
return null;
final String base32 = aBase32.toUpperCase();
int i, index, lookup, offset, digit;
byte[] bytes = new byte[base32.length() * 5 / 8];
for (i = 0, index = 0, offset = 0; i < base32.length(); i++) {
lookup = base32.charAt(i) - '0';
/* Skip chars outside the lookup table */
if (lookup < 0 || lookup >= BASE32_LOOKUP.length) {
continue;
}
digit = BASE32_LOOKUP[lookup];
/* If this digit is not in the table, ignore it */
if (digit == 0xFF) {
continue;
}
if (index <= 3) {
index = (index + 5) % 8;
if (index == 0) {
bytes[offset] |= digit;
offset++;
if (offset >= bytes.length)
break;
} else {
bytes[offset] |= digit << (8 - index);
}
} else {
index = (index + 5) % 8;
bytes[offset] |= (digit >>> index);
offset++;
if (offset >= bytes.length) {
break;
}
bytes[offset] |= digit << (8 - index);
}
}
return bytes;
}
/**
* Convert a byte array to a String with a hexidecimal format.
*
* @param data
* byte array
* @param offset
* starting byte (zero based) to convert.
* @param length
* number of bytes to convert.
*
* @return the String (with hexidecimal format) form of the byte array
*/
private static String toHexString(byte[] data, int offset, int length) {
if (data == null || data.length == 0)
return "";
final StringBuffer s = new StringBuffer(length * 3);
for (int i = offset; i < offset + length; i++) {
s.append(HEX_TABLE[(data[i] & 0xf0) >>> 4]);
s.append(HEX_TABLE[(data[i] & 0x0f)]);
if ((i - offset) % 2 == 1)
s.append(" ");
}
return s.toString();
}
/**
* Generates a new random secret key.
*
* @return secret key suitable for selected HMac Algorithm
*/
private byte[] generateNewKey() {
byte[] result = new byte[HMAC_BYTE_COUNT[chgHmacAlgorithm.getSelectedIndex()]];
for (int i = 0, len = result.length; i < len;)
for (int rnd = rand.nextInt(), n = Math.min(len - i, 4); n-- > 0; rnd >>= 8)
result[i++] = (byte) rnd;
return result;
}
/**
* Zero-left-padding for integer values. If a length of given integer
* converted to string is smaller than len, then zeroes are filled on the
* left side so the resulting string has lenght=len.
*
* @param value
* @param len
* @return
*/
private static String zeroLeftPad(int value, int len) {
final String strValue = String.valueOf(value);
if (strValue.length() >= len)
return strValue;
final StringBuffer sb = new StringBuffer(len);
for (int i = strValue.length(); i < len; i++) {
sb.append("0");
}
return sb.append(strValue).toString();
}
/**
* Sorts profiles by the name alphabetically. It updates both listProfiles
* and recordIds member variables.
*/
private void reorderProfiles() {
final int n = listProfiles.size();
if (n < 2)
return;
debug("Sorting profiles alphabetically.");
final int selectedIndex = listProfiles.getSelectedIndex();
final int selectedRecordId = selectedIndex >= 0 ? recordIds[selectedIndex] : -1;
for (int i = n - 1; i > 0; i--) {
for (int j = 0; j < i; j++) {
final String thisStr = listProfiles.getString(j);
final String nextStr = listProfiles.getString(j + 1);
if (thisStr.compareTo(nextStr) > 1) {
listProfiles.set(j, nextStr, null);
listProfiles.set(j + 1, thisStr, null);
final int thisId = recordIds[j];
recordIds[j] = recordIds[j + 1];
recordIds[j + 1] = thisId;
}
}
}
if (selectedRecordId >= 0) {
for (int i = 0; i < recordIds.length; i++) {
if (selectedRecordId == recordIds[i]) {
listProfiles.setSelectedIndex(i, true);
break;
}
}
}
}
// Embedded classes ------------------------------------------------------
/**
* Task for refreshing the token.
*/
private class RefreshTokenTask extends TimerTask {
public final void run() {
int timeStep = -1;
try {
timeStep = Integer.parseInt(tfTimeStep.getString());
} catch (NumberFormatException e) {
debugErr(e.getMessage());
}
int remainSec = -1;
if (timeStep > 0) {
long delta = DEFAULT_DELTA;
try {
delta = Long.parseLong(tfDelta.getString());
} catch (NumberFormatException e) {
debugErr(e.getMessage());
}
final long currentTimeSec = System.currentTimeMillis() / 1000L + delta;
final long newCounter = getCounter(currentTimeSec, timeStep);
if (cachedCounter != newCounter) {
int digits = -1;
try {
digits = Integer.parseInt(tfDigits.getString());
} catch (NumberFormatException e) {
debugErr(e.getMessage());
}
siToken.setText(genToken(newCounter, getHMac(), digits));
cachedCounter = newCounter;
}
if (timeStep == 1 || "".equals(siToken.getText())) {
remainSec = IDLE;
} else {
remainSec = (int) (timeStep - 1 - currentTimeSec % timeStep);
}
} else {
remainSec = 0;
siToken.setText("");
cachedCounter = INVALID_COUNTER;
}
// set values (and repaint) only if needed
if (gauValidity.getValue() != remainSec) {
gauValidity.setValue(remainSec);
}
}
}
}